---
id: pivot-utils
title: 透视工具
---

透视能力增强相关工具。包括了以下方法和相关的 TypeScript 类型定义，通过这些工具你可以快速地从明细数据中构建并定制交叉表/透视表。

- `buildDrillTree`: 从明细数据中构建下钻树
- `buildRecordMatrix`: 从明细数据中构建 RecordMatrix
- `convertDrillTreeToCrossTree`: 将下钻树转换为 CrossTable 的输入

## 数据结构——下钻树

**下钻树**用来表示*数据按照一定的维度序列被不断分组后形成的树结构*。ali-react-table 提供了 `buildDrillTree` 方法来从数据中构建下钻树。

### 下钻树构建示例

```jsx live
function DrillTreeIllustration() {
  const dwdData = [
    { subs: '上海子公司', shop: '上海大宁店', month: '2022-01', valueA: 782, valueB: 566 },
    { subs: '上海子公司', shop: '上海大宁店', month: '2022-02', valueA: 856, valueB: 403 },
    { subs: '上海子公司', shop: '上海大宁店', month: '2022-03', valueA: 886, valueB: 555 },
    { subs: '上海子公司', shop: '上海大宁店', month: '2022-04', valueA: 555, valueB: 444 },
    { subs: '上海子公司', shop: '上海大宁店', month: '2022-05', valueA: 444, valueB: 333 },
    { subs: '上海子公司', shop: '上海曹家渡店', month: '2022-01', valueA: 922, valueB: 778 },
    { subs: '上海子公司', shop: '上海曹家渡店', month: '2022-02', valueA: 888, valueB: 887 },
    { subs: '上海子公司', shop: '上海曹家渡店', month: '2022-03', valueA: 879, valueB: 870 },
    { subs: '上海子公司', shop: '上海曹家渡店', month: '2022-04', valueA: 800, valueB: 723 },
    { subs: '上海子公司', shop: '上海曹家渡店', month: '2022-05', valueA: 813, valueB: 789 },
    { subs: '浙江子公司', shop: '亲橙里', month: '2022-01', valueA: 500, valueB: 463 },
    { subs: '浙江子公司', shop: '亲橙里', month: '2022-02', valueA: 833, valueB: 456 },
    { subs: '浙江子公司', shop: '亲橙里', month: '2022-03', valueA: 821, valueB: 442 },
    { subs: '浙江子公司', shop: '亲橙里', month: '2022-04', valueA: 834, valueB: 456 },
    { subs: '浙江子公司', shop: '亲橙里', month: '2022-05', valueA: 803, valueB: 700 },
  ]

  const drillTree = buildDrillTree(dwdData, ['subs', 'shop', 'month'])

  return (
    <div style={{ border: '1px dashed #ccc', padding: 8 }}>
      <div style={{ fontSize: 14, lineHeight: 2 }}>1. 输入数据（输入格式为一个对象的数组）</div>
      <docHelpers.Inspector data={dwdData} />

      <div style={{ marginTop: 16, fontSize: 14, lineHeight: 2 }}>
        <div>2. 处理过程</div>
        <code>buildDrillTree(data, ['subs', 'shop', 'month'])</code>
      </div>

      <div style={{ marginTop: 16, fontSize: 14, lineHeight: 2 }}>3. 输出一个 DrillNode 的数组</div>
      <docHelpers.Inspector data={drillTree} />
    </div>
  )
}
```

### 下钻树节点结构

下钻树中的树节点 `DrillNode` 结构如下：

```typescript
interface DrillNode {
  key: string
  value: string
  path: string[]
  children?: DrillNode[]
  hasChild?: boolean
}
```

- `key` 用于唯一标记该节点
- `value` 字段表示该节点的值
- `path` 表示下钻树中到当前节点路径上的值的序列
- `children` 记录了当前节点的子节点数组
- `hasChild` 用于标记当前是否有子节点

### buildDrillTree

在构建下钻树时，`buildDrillTree(data, codes, options?)` 的 options 参数可以调整默认的构建配置：

- `options.encode`
  - 默认情况下，buildDrillTree 会使用默认的 `simpleEncode` 来编码 `path` 来生成 key 字段
  - 你也可以通过 `options.encode` 提供一个自定义的编码函数用于自定义 key 的生成方式。
- `options.includeTopWrapper`
  - 默认情况下，buildDrillTree 不会生成「总计」节点。
  - 当 `options.includeTopWrapper = true` 时，buildDrillTree 返回的结果的第一层将为「总计」节点
  - 注意 buildDrillTree 的返回结果总是一个数组，即使数组中只有一个「总计」节点
- `options.topValue`：用于指定「总计」节点的 `value`，默认为 `"总计"`
- `options.isExpand`：根据节点的 key 判断一个节点是否展开，没有展开的节点将不进行下钻；默认为 `(key: string) => true`

### `options.isExpand` 与剪枝优化

当数据量较大且维度序列较深时，buildDrillTree 会构建出一棵非常庞大的树，但后续使用时我们往往并不需要用到所有的节点，大部分节点在使用时都是被「折叠」的。isExpand 回调可以告诉 buildDrillTree 哪些节点被展开了，哪些节点是收拢的。当 `isExpand(key)` 返回值为 false 时，buildDrillTree 将跳过当前节点的子节点的生成过程。

在当前节点的子节点被跳过的时候，buildDrillTree 会为当前节点设置 `node.hasChild = true`。

### 交叉表与下钻树

交叉表左侧/上方的数据结构与下钻树非常类似：

- 节点的主要字段均为 `key` / `value` / `children`
- 简单情况下，下钻树可以直接作为 CrossTable 的 `leftTree` / `topTree`

但两者描述的结构是不同的：

- `LeftCrossTreeNode` / `TopCrossTreeNode` 描述了表格结构，是**展现层面**的结构；
- `DrillNode` 描述数据被不断分组后的结果，是**数据层面**的结构。

很多时候，下钻树与交叉表的 leftTree/topTree 并不是对应的，ali-react-table 提供了一些其他方法来在两者之间进行转换。

## 数据结构——数据立方

下钻树具有一个下钻维度序列；而透视表（不要过分纠结这里透视表的含义，这里的透视表指的只是简单的多维度行列交叉表）具有两个下钻维度序列：左侧维度序列（也称行维度），以及上方维度序列（也成列维度）。在实践中，我们可以使用「左侧的值序列」+「上方的值序列」来唯一确定透视表中的每一个单元格。对这两个值序列进行编码（分别记为 leftKey 和 topKey），我们可以用两个字符串来表示一个单元格。

RecordMatrix 是透视表的数据源容器，存放了一个透视表所有单元格的数据。RecordMatrix 的数据结构是一个二维的 Map，类型为 `type RecordMatrix = Map<string, Map<string, any>>`。根据 leftKey 和 topKey，从 matrix 中可以快速找到对应单元格的数据，对应的使用方式为 `matrix.get(leftKey).get(topKey)`.

### `buildRecordMatrix` 从明细数据中构建数据立方

RecordMatrix 的用法非常简单，但其构建过程则相对麻烦一些。ali-react-table/pivot 提供了 `buildRecordMatrix` 方法，该方法用于从明细数据中根据两个下钻维度序列生成相应的数据立方，具体接口如下：

```typescript
function buildRecordMatrix(buildConfig: BuildRecordMatrixConfig): RecordMatrix
```

`BuildRecordMatrixConfig` 结构：

| 字段               | 类型                              | 必传/可选 | 说明                                          |
| ------------------ | --------------------------------- | --------- | --------------------------------------------- |
| `leftCodes`        | `string[]`                        | 必传      | 表格左侧 下钻维度（行维度）                   |
| `topCodes`         | `string[]`                        | 必传      | 表格上方 下钻维度（列维度）                   |
| `data`             | `any[]`                           | 必传      | 明细数据，格式为 对象数组                     |
| `aggregate`        | `(slice:any[]) => any`            | 可选      | 聚合函数，若不提供则不对数据进行聚合          |
| `encode`           | `(valuePath: string[]) => string` | 可选      | 参见上方 `buildDrillTree` 文档中的 `encode`   |
| `isLeftExpand`     | `(key: string) => boolean`        | 可选      | 参见上方 `buildDrillTree` 文档中的 `isExpand` |
| `isTopExpand`      | `(key: string) => boolean`        | 可选      | 参见上方 `buildDrillTree` 文档中的 `isExpand` |
| `prebuiltLeftTree` | `DrillNode[]`                     | 可选      | 预先构建好的 左侧下钻树                       |
| `prebuiltTopTree`  | `DrillNode[]`                     | 可选      | 预先构建好的 上方下钻树                       |

注意事项：

- 如果提供了 `prebuiltLeftTree`，预构建的左侧下钻树必须使用相同的 leftCodes 和 data 进行构建，且构建时必须包含总计节点。（同理于 prebuiltTopTree）

### `buildRecordMatrix` 示例

下面的例子描述了 buildDrillTree，buildRecordMatrix 以及交叉表是如何配套使用的。在下面的示例中，我们直接将 下钻树作为了交叉表组件的 leftTree 和 topTree，故 leftTree/topTree 中节点的 key 与 matrix 中的索引是一致的。在交叉表的 getValue 回调中，我们直接使用 leftNode.key 与 topNode.key 就能从 matrix 取出对应单元格的数据。

```jsx live
function SimpleCrossTable() {
  const data = [
    { subs: '上海子公司', shop: '上海大宁店', season: '一季度', month: '2022-01', valueA: 782, valueB: 566 },
    { subs: '上海子公司', shop: '上海大宁店', season: '一季度', month: '2022-02', valueA: 856, valueB: 403 },
    { subs: '上海子公司', shop: '上海大宁店', season: '一季度', month: '2022-03', valueA: 886, valueB: 555 },
    { subs: '上海子公司', shop: '上海大宁店', season: '二季度', month: '2022-04', valueA: 555, valueB: 444 },
    { subs: '上海子公司', shop: '上海大宁店', season: '二季度', month: '2022-05', valueA: 444, valueB: 333 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '一季度', month: '2022-01', valueA: 922, valueB: 778 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '一季度', month: '2022-02', valueA: 888, valueB: 887 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '一季度', month: '2022-03', valueA: 879, valueB: 870 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '二季度', month: '2022-04', valueA: 800, valueB: 723 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '二季度', month: '2022-05', valueA: 813, valueB: 789 },
    { subs: '浙江子公司', shop: '亲橙里', season: '一季度', month: '2022-01', valueA: 500, valueB: 463 },
    { subs: '浙江子公司', shop: '亲橙里', season: '一季度', month: '2022-02', valueA: 833, valueB: 456 },
    { subs: '浙江子公司', shop: '亲橙里', season: '一季度', month: '2022-03', valueA: 821, valueB: 442 },
    { subs: '浙江子公司', shop: '亲橙里', season: '二季度', month: '2022-04', valueA: 834, valueB: 456 },
    { subs: '浙江子公司', shop: '亲橙里', season: '二季度', month: '2022-05', valueA: 803, valueB: 700 },
  ]
  const leftDims = [
    { code: 'subs', name: '子公司' },
    { code: 'shop', name: '门店' },
  ]
  const topDims = [
    { code: 'season', name: '季度' },
    { code: 'month', name: '月份' },
  ]
  const leftCodes = leftDims.map((dim) => dim.code)
  const topCodes = topDims.map((dim) => dim.code)
  const leftTree = buildDrillTree(data, leftCodes)
  const topTree = buildDrillTree(data, topCodes)
  const matrix = buildRecordMatrix({ data, leftCodes, topCodes })
  return (
    <CrossTable
      defaultColumnWidth={100}
      leftTree={leftTree}
      topTree={topTree}
      leftMetaColumns={leftDims}
      getValue={(leftNode, topNode) => {
        const record = matrix.get(leftNode.key).get(topNode.key)
        return record.valueA
      }}
    />
  )
}
```

### `convertDrillTreeToCrossTree`

「下钻树直接作为 leftTree 和 topTree」虽然方便，但有一些明显的不足：

- 缺少总计/小计节点，需要手动添加
- 无法满足多个指标同时展示的需求
- 不支持收拢展开功能
- 小计节点与父节点共用同一份数据，但在表格结构上两者又必须使用不同的 key，两者发生冲突

这些限制的原因在于**表格展现层和数据层使用了相同的 key**，导致两者必须具有相同的结构。 `convertDrillTreeToCrossTree` 用于将下钻树转换为交叉表的 leftTree/topTree，在转换过程中为展现层和数据层分配不同的 key 从而解除了上述限制。并提供以下特性：

- 支持单指标或多指标，多指标情况下支持指标放在交叉表左侧或上方
- 支持生成小计节点
- 支持收拢展开

转换过程中，该函数会执行以下处理：

- 该函数会将下钻树中的 key 复制到 `crossTreeNode.data.dataKey` 上（crossTreeNode 指生成的交叉树节点）
- 该函数会将下钻树中的 path 复制到 `crossTreeNode.data.dataPath` 上
- 根据多指标或小计节点相关配置，生成对应的 CrossTreeNode，并为新的节点生成 key 值
  - 多指标的情况下，指标配置会被复制到 `crossTreeNode.data.indicator` 上
  - 新增的节点中，data.dataKey 和 data.dataPath 保存了对应下钻树节点的 key 和 path
- 开启展开/收拢功能后，为部分 CrossTreeNode 生成 title 用于渲染交互按钮

转换函数具体 api:

```typescript
function convertDrillTreeToCrossTree<T extends CrossTreeNode = CrossTreeNode>(
  drillTree: DrillNode[],
  options: ConvertOptions<T> = {},
): T[]

type ConvertOptions<T extends CrossTreeNode = CrossTreeNode> = {
  /** 需要在子节点处附加的 指标节点 */
  indicators?: CrossTableIndicator[]

  /** 自定义的编码函数，用于根据下钻的值序列生成唯一的字符串.
   * 该参数留空 表示使用默认的编码方式 */
  encode?(valuePath: string[]): string

  /** 为一个值序列生成小计（sub-total）节点.
   * 针对每一个父节点，该函数都将被调用一次；
   * * 函数返回 null, 表示对应父节点不需要小计节点；
   * * 返回 `{ position: 'start' | 'end', value: string; data?: any }`
   *  表明所要生成的小计节点的摆放位置、文本、附加的数据
   *
   * 该参数留空 表示所有节点均不需要生成小计节点 */
  generateSubtotalNode?(
    drillNode: DrillNode,
  ): null | {
    position: 'start' | 'end'
    value: string
  }

  /** 是否支持节点的展开与收拢，默认为 false。
   * 当该选项为 true 时，展开/收拢才会开启，相关的按钮也才会被渲染 */
  supportsExpand?: boolean

  /** 展开的节点的 key 数组 */
  expandKeys?: string[]

  /** 展开节点发生变化时的回调 */
  onChangeExpandKeys?(nextKeys: string[], targetNode: DrillNode, action: 'collapse' | 'expand'): void

  /** 是否强制展开总计节点，默认为 true */
  enforceExpandTotalNode?: boolean
}
```

### 基于交叉表和透视工具构建透视表

有了这些透视工具和合适的渲染层，接下来我们就可以从明细数据中构建透视表了。下面这个例子中引入了一个简单的透视表设计器，提供了最基本的维度选择和表格结构调整功能。根据设计器的状态，调整构建下钻树或转换时的参数，就能够方便地实现透视表的各项功能了。

```jsx live
function SimplePivotTable() {
  const data = [
    { subs: '上海子公司', shop: '上海大宁店', season: '一季度', month: '2022-01', valueA: 782, valueB: 566 },
    { subs: '上海子公司', shop: '上海大宁店', season: '一季度', month: '2022-02', valueA: 856, valueB: 403 },
    { subs: '上海子公司', shop: '上海大宁店', season: '一季度', month: '2022-03', valueA: 886, valueB: 555 },
    { subs: '上海子公司', shop: '上海大宁店', season: '二季度', month: '2022-04', valueA: 555, valueB: 444 },
    { subs: '上海子公司', shop: '上海大宁店', season: '二季度', month: '2022-05', valueA: 444, valueB: 333 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '一季度', month: '2022-01', valueA: 922, valueB: 778 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '一季度', month: '2022-02', valueA: 888, valueB: 887 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '一季度', month: '2022-03', valueA: 879, valueB: 870 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '二季度', month: '2022-04', valueA: 800, valueB: 723 },
    { subs: '上海子公司', shop: '上海曹家渡店', season: '二季度', month: '2022-05', valueA: 813, valueB: 789 },
    { subs: '浙江子公司', shop: '亲橙里', season: '一季度', month: '2022-01', valueA: 500, valueB: 463 },
    { subs: '浙江子公司', shop: '亲橙里', season: '一季度', month: '2022-02', valueA: 833, valueB: 456 },
    { subs: '浙江子公司', shop: '亲橙里', season: '一季度', month: '2022-03', valueA: 821, valueB: 442 },
    { subs: '浙江子公司', shop: '亲橙里', season: '二季度', month: '2022-04', valueA: 834, valueB: 456 },
    { subs: '浙江子公司', shop: '亲橙里', season: '二季度', month: '2022-05', valueA: 803, valueB: 700 },
  ]
  const dimensions = [
    { code: 'subs', name: '子公司' },
    { code: 'shop', name: '门店' },
    { code: 'season', name: '季度' },
    { code: 'month', name: '月份' },
  ]
  const indicators = [
    { code: 'valueA', name: '指标A', width: 100, align: 'right', expression: 'SUM(valueA)' },
    { code: 'valueB', name: '指标B', width: 100, align: 'right', expression: 'SUM(valueB)' },
  ]
  const [indicatorSide, onChangeIndicatorSide] = useState('top')
  const [topCodes, onChangeTopCodes] = useState(['season'])
  const [leftCodes, onChangeLeftCodes] = useState(['subs', 'shop'])
  const [showSubtotal, onChangeShowSubtotal] = useState(true)

  function generateSubtotalNode(drillNode) {
    return {
      position: 'start', // 这里改成 end 可以将总计/小计节点 放到末尾
      value: drillNode.path.length === 0 ? '总计' : '小计',
    }
  }
  const leftDrillTree = buildDrillTree(data, leftCodes, { includeTopWrapper: true })
  const [leftTreeRoot] = convertDrillTreeToCrossTree(leftDrillTree, {
    indicators: indicatorSide === 'left' ? indicators : null,
    generateSubtotalNode: showSubtotal ? generateSubtotalNode : null,
  })
  const topDrillTree = buildDrillTree(data, topCodes, { includeTopWrapper: true })
  const [topTreeRoot] = convertDrillTreeToCrossTree(topDrillTree, {
    indicators: indicatorSide === 'top' ? indicators : null,
    generateSubtotalNode: showSubtotal ? generateSubtotalNode : null,
  })
  // import { createAggregateFunction } from 'dvt-aggregation'
  const aggregate = createAggregateFunction(indicators)
  // aggregate 是一个简单的聚合函数，你可以在这里使用 console.log(aggregate) 来查看该函数的定义

  const matrix = buildRecordMatrix({ data, leftCodes, topCodes, aggregate })
  return (
    <div>
      <assets.MinimumPivotTableDesigner
        showSubtotal={showSubtotal}
        onChangeShowSubtotal={onChangeShowSubtotal}
        dimensions={dimensions}
        leftCodes={leftCodes}
        onChangeLeftCodes={onChangeLeftCodes}
        topCodes={topCodes}
        onChangeTopCodes={onChangeTopCodes}
        indicatorSide={indicatorSide}
        onChangeIndicatorSide={onChangeIndicatorSide}
      />
      <CrossTable
        defaultColumnWidth={100}
        leftTree={leftTreeRoot.children}
        topTree={topTreeRoot.children || [topTreeRoot]}
        getValue={(leftNode, topNode) => {
          // 注意这里我们使用 node.data.dataKey 来获取单元格在 matrix 中的 record
          const record = matrix.get(leftNode.data.dataKey).get(topNode.data.dataKey)
          if (record == null) {
            return '-'
          }
          const indicator = leftNode.data.indicator || topNode.data.indicator
          return record[indicator.code]
        }}
        // ======================================
        // 当 leftTree 为空时，leftTotalNode 用于渲染总计行，且可以避免生成额外的表格列
        // 当 topTree 为空时，topTotalNode 用于渲染总计列
        // leftTotalNode / topTotalNode 仅用于表格结构优化，一般情况下可以不传
        leftTotalNode={leftTreeRoot}
        topTotalNode={topTreeRoot}
      />
    </div>
  )
}
```
